跳到主要内容

SpringSecurity 原理篇 鉴权流程源码解析

上篇笔记详细的介绍了 SpringSecurity 的认证过程,现在这部分来补充它的动态鉴权部分

鉴权原理

整个认证的过程其实一直在围绕图中过滤链的绿色部分,而动态鉴权主要是围绕其橙色部分,也就是图上标的:FilterSecurityInterceptor。

经常能看到下面这张图

实际上通过 Spring Security 内置的一个 Security Interceptor 委托给各个具体的 AccessDecisionManager

FilterSecurityInterceptor

上图已经说明了 Spring Security 里面有个 Security Interceptor 会委托给各个具体的 AccessDecisionManager 来做具体的工作,这个过滤器就是 FilterSecurityInterceptor

一个请求完成了认证,且没有抛出异常之后就会到达 FilterSecurityInterceptor 所负责的鉴权部分,也就是说鉴权的入口就在 FilterSecurityInterceptor。

类图如下所示:

先来看看 FilterSecurityInterceptor 的定义和主要方法:

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {

public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 这个 FilterInvocation 可以当作它封装了 request,它的主要工作就是拿请求里面的信息,比如请求的 URI。
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
}

关键就在于这个 invoke 方法

public void invoke(FilterInvocation fi) throws IOException, ServletException {
// 健壮性判断略...

// 进入鉴权 ⭐
InterceptorStatusToken token = super.beforeInvocation(fi);

try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}

可以发现它分别调用了父类(AbstractSecurityInterceptor)的三个 Invocation 方法

  • beforeInvocation
  • finallyInvocation
  • afterInvocation

这里就只讲 beforeInvocation,其它的几个都大同小异,beforeInvocation 顾名思义,它是第一个被执行的,学习访问控制时,可以看到有下面两个注解

  • @PreAuthorize 在方法执行前再进行权限验证
  • @PostAuthorize 在方法执行后再进行权限验证

实际上就是对应着上面的各个 Invocation 执行的时期

那来看下 Invocation 里面具体做了什么

// 注意,新版这里有点变化调用 accessDecisionManager 使用的是 attemptAuthorization 方法
// 不过也是在 beforeInvocation 内部调用的,所以实际影响不大
protected InterceptorStatusToken beforeInvocation(Object object) {
// 健壮性判断略...

// 这个对象是一个 List,里面就是在配置文件中配置的过滤规则
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);

Authentication authenticated = authenticateIfRequired();

try {
// 鉴权需要调用的接口
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
accessDeniedException));

throw accessDeniedException;
}

}

源码较长,精简了部分代码,这段代码大致可以分为三步:

1 拿到了一个 Collection<ConfigAttribute> 对象,这个对象是一个 List,其实里面就是我们在配置文件中配置的过滤规则。

2 拿到了 Authentication,这里是调用 authenticateIfRequired 方法拿到了,其实方法里面还是通过 调用 SecurityContextHolder 拿到的

3 调用了 accessDecisionManager.decide(authenticated, object, attributes),前两步都是对 decide 方法做参数的准备,第三步才是正式去到鉴权的逻辑,既然这里面才是真正鉴权的逻辑,那也就是说鉴权其实是 accessDecisionManager 在做。

AccessDecisionManager

前面通过源码我们看到了鉴权的真正处理者:AccessDecisionManager

它是如何工作的呢?

AccessDecisionManager 是一个接口,它声明了三个方法,除了第一个鉴权方法以外,还有两个是辅助性的方法,其作用都是甄别 decide方法中参数的有效性。

public interface AccessDecisionManager {

// 主要鉴权方法
void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
InsufficientAuthenticationException;

boolean supports(ConfigAttribute attribute);

boolean supports(Class<?> clazz);
}

那既然是一个接口,上文中所调用的肯定是他的实现类了,我们来看看这个接口的结构树:

从图中我们可以看到它主要有三个实现类,分别代表了三种不同的鉴权逻辑:

  • AffirmativeBased:一票通过,只要有一票通过就算通过,默认是它。
  • UnanimousBased:一票反对,只要有一票反对就不能通过。
  • ConsensusBased:少数票服从多数票。

这里的表述为什么要用票呢?因为在实现类里面采用了委托的形式,将请求委托给投票器(AccessDecisionVoter),每个投票器拿着这个请求根据自身的逻辑来计算出能不能通过然后进行投票,所以会有上面的表述。

也就是说这三个实现类,其实还不是真正判断请求能不能通过的类,真正判断请求是否通过的是投票器,然后实现类把投票器的结果综合起来来决定到底能不能通过。

AffirmativeBased

刚刚已经说过,实现类把投票器的结果综合起来进行决定,也就是说投票器可以放入多个,每个实现类里的投票器数量取决于构造的时候放入了多少投票器,我们可以看看默认的 AffirmativeBased 的源码。

public class AffirmativeBased extends AbstractAccessDecisionManager {

public AffirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
super(decisionVoters);
}

// 拿到所有的投票器,循环遍历进行投票
public void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;

// 遍历投票器
for (AccessDecisionVoter voter : getDecisionVoters()) {
// 可以注意到实际是委托给 AccessDecisionVoter 的 vote 方法进行认证
int result = voter.vote(authentication, object, configAttributes);

if (logger.isDebugEnabled()) {
logger.debug("Voter: " + voter + ", returned: " + result);
}

switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;

case AccessDecisionVoter.ACCESS_DENIED:
deny++;

break;

default:
break;
}
}

if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}

// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}
}

AffirmativeBased 的构造是传入投票器 List,其主要鉴权逻辑交给投票器(AccessDecisionVoter)去判断,投票器返回不同的数字代表不同的结果,然后 AffirmativeBased 根据自身一票通过的策略决定放行还是抛出异常。

AffirmativeBased 默认传入的构造器只有一个 WebExpressionVoter,这个构造器会根据你在配置文件中的配置进行逻辑处理得出投票结果。

所以 SpringSecurity 默认的鉴权逻辑就是根据配置文件中的配置进行鉴权,这是符合我们现有认知的。

使用此方法,将根据授权决策轮询一系列 AccessDecisionVoter 实现。然后 AccessDecisionManager 根据其对投票的评估来决定是否抛出 AccessDeniedException

AccessDecisionVoter

上面说了访问控制的过程和认证的过程很详细,都是通过委托给第三方去实现的,在认证中,这个 “第三方” 是 AuthenticationProvider 而到了访问控制中 “第三方” 则是这个 AccessDecisionVoter(投票器)

它有如下几个实现类

这里就只介绍一下 RoleVoter 这个投票器,其它的都差不多,顾名思义,它基于任何一个以 “ROLE_” 开头的配置属性进行投票。如果符合条件,则搜索认证对象的 GrantedAuthority 列表。

// 看上一节的源码,认证执行的是这个方法
@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
if (authentication == null) {
return ACCESS_DENIED;
}

int result = ACCESS_ABSTAIN;
// 这里是比对下当前要访问的用户角色是否满足 API 所支持的 GrantedAuthority
Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);
for (ConfigAttribute attribute : attributes) {
if (this.supports(attribute)) {
result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : authorities) {
if (attribute.getAttribute().equals(authority.getAuthority())) {
// 找到了满足的角色抛出成功
return ACCESS_GRANTED;
}
}
}
}
return result;
}

动态鉴权实现

通过上面一步步的讲述,我想你也应该理解了 SpringSecurity 到底是什么实现鉴权的,那我们想要做到动态的给予某个角色不同的访问权限应该怎么做呢?

动态鉴权几种方案

既然是动态鉴权了,那我们的权限 URI 肯定是放在数据库中了,我们要做的就是实时的在数据库中去读取不同角色对应的权限然后与当前登录的用户做个比较。

那我们要做到这一步可以想些方案,比如:

1 直接重写一个 AccessDecisionManager,将它用作默认的 AccessDecisionManager,并在里面直接写好鉴权逻辑。

2 重写一个投票器,将它放到默认的 AccessDecisionManager 里面,和之前一样用投票器鉴权。

3 直接去做 FilterSecurityInterceptor 的改动。

本文采用第二种方式

重写投票器思路

那么我们需要写一个新的投票器,在这个投票器里面拿到当前用户的角色,使其和当前请求所需要的角色做个对比。

单单是这样还不够,因为我们可能在配置文件中也配置的有一些放行的权限,比如登录 URI 就是放行的,所以我们还需要继续使用我们上文所提到的 WebExpressionVoter,也就是说需要 “自定义权限” + “配置文件” 双行的模式,所以我们的 AccessDecisionManager 里面就会有两个投票器:WebExpressionVoter 和自定义的投票器。

紧接着 还需要考虑去使用什么样的投票策略,这里使用的是 UnanimousBased 一票反对策略,而没有使用默认的一票通过策略,因为在我们的配置中配置了除了登录请求以外的其他请求都是需要认证的,这个逻辑会被 WebExpressionVoter 处理,如果使用了一票通过策略,那我们去访问被保护的 API 的时候,WebExpressionVoter 发现当前请求认证了,就直接投了赞成票,且因为是一票通过策略,这个请求就走不到我们自定义的投票器了。

注:也可以不用配置文件中的配置,将自定义权限配置都放在数据库中,然后统一交给一个投票器来处理,但是这样有点极端,配置文件直接就无效了,一般还是采用双行的模式

重新构造 AccessDecisionManager

首先重新构造 AccessDecisionManager, 因为投票器是系统启动的时候自动添加进去的,所以我们想多加入一个构造器必须自己重新构建 AccessDecisionManager,然后将它放到配置中去。

而且我们的投票策略已经改变了,要由 AffirmativeBased 换成 UnanimousBased,所以这一步是必不可少的。

并且我们还要自定义一个投票器起来,将它注册成Bean,AccessDecisionProcessor 就是我们需要自定义的投票器。

// 注册自定义的投票器(这个投票器的实现看下面)
@Bean
public AccessDecisionVoter<FilterInvocation> accessDecisionProcessor() {
return new AccessDecisionProcessor();
}

@Bean
public AccessDecisionManager accessDecisionManager() {
// 构造一个新的 AccessDecisionManager 放入两个投票器
List<AccessDecisionVoter<?>> decisionVoters = Arrays.asList(new WebExpressionVoter(), accessDecisionProcessor());
return new UnanimousBased(decisionVoters);
}

定义完 AccessDecisionManager 之后,我们将它放入启动配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 放行所有OPTIONS请求
.antMatchers(HttpMethod.OPTIONS).permitAll()
// 放行登录方法
.antMatchers("/api/auth/login").permitAll()
// 其他请求都需要认证后才能访问
.anyRequest().authenticated()
// 使用自定义的 accessDecisionManager ⭐
.accessDecisionManager(accessDecisionManager())
.and()
// 添加未登录与权限不足异常处理器
.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler())
.authenticationEntryPoint(restAuthenticationEntryPoint())
.and()
// 将自定义的JWT过滤器放到过滤链中
.addFilterBefore(
jwtAuthenticationTokenFilter(),
UsernamePasswordAuthenticationFilter.class)
// 打开Spring Security的跨域
.cors()
.and()
// 关闭CSRF
.csrf().disable()
// 关闭Session机制
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}

这样之后,SpringSecurity 里面的 AccessDecisionManager 就会被替换成我们自定义的 AccessDecisionManager了。

自定义鉴权实现

上文配置中放入了两个投票器,其中第二个投票器就是需要创建的投票器,起名为 AccessDecisionProcessor。

投票其也是有一个接口规范的,只需要实现这个 AccessDecisionVoter 接口就行了,然后实现它的方法。

具体的实现返回 int,可能的值反映在 AccessDecisionVoter 静态字段

  • ACCESS_ABSTAIN
  • ACCESS_DENIED
  • ACCESS_GRANTED

如果投票实施对授权决定没有意见,则将返回 ACCESS_ABSTAIN。如果确实有意见,则必须返回 ACCESS_DENIED 或 ACCESS_GRANTED

@Slf4j
public class AccessDecisionProcessor implements AccessDecisionVoter<FilterInvocation> {
@Autowired
private Cache caffeineCache;
// 这个缓存是自定义的,细节看作者的源码
// https://github.com/he-erduo/spring-boot-learning-demo/blob/master/spring-security-demo/src/main/java/org/example/security/auth/cache/CaffeineCache.java

// 投票
@Override
public int vote(Authentication authentication, FilterInvocation object, Collection<ConfigAttribute> attributes) {
assert authentication != null;
assert object != null;

// 拿到当前请求uri
String requestUrl = object.getRequestUrl();
String method = object.getRequest().getMethod();
log.debug("进入自定义鉴权投票器,URI : {} {}", method, requestUrl);

String key = requestUrl + ":" + method;
// 如果没有缓存中没有此权限也就是未保护此API,弃权
PermissionInfoBO permission = caffeineCache.get(CacheName.PERMISSION, key, PermissionInfoBO.class);
if (permission == null) {
return ACCESS_ABSTAIN;
}

// 拿到当前用户所具有的权限
List<String> roles = ((UserDetail) authentication.getPrincipal()).getRoles();
if (roles.contains(permission.getRoleCode())) {
return ACCESS_GRANTED;
}else{
return ACCESS_DENIED;
}
}

@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}

@Override
public boolean supports(Class<?> clazz) {
return true;
}
}

大致逻辑是这样:我们以 URI + METHOD 为 key 去缓存中查找权限相关的信息,如果没有找到此 URI,则证明这个 URI 没有被保护,投票器可以直接弃权。

如果找到了这个 URI 相关权限信息,则用其与用户自带的角色信息做一个对比,根据对比结果返回 ACCESS_GRANTED 或 ACCESS_DENIED。

当然这样做有一个前提,那就是在系统启动的时候就把 URI 权限数据都放到缓存中了,系统一般在启动的时候都会把热点数据放入缓存中,以提高系统的访问效率。

@Component
public class InitProcessor {
@Autowired
private PermissionService permissionService;
@Autowired
private Cache caffeineCache;

@PostConstruct
public void init() {
List<PermissionInfoBO> permissionInfoList = permissionService.listPermissionInfoBO();
permissionInfoList.forEach(permissionInfo -> {
caffeineCache.put(CacheName.PERMISSION, permissionInfo.getPermissionUri() + ":" + permissionInfo.getPermissionMethod(), permissionInfo);
});
}
}

这里考虑到权限 URI 可能非常多,所以将权限 URI 作为 key 放到缓存中,因为一般缓存中通过 key 读取数据的速度是 O(1)O(1) ,所以这样会非常快。

至此就执行完毕了,鉴权的逻辑到底如何处理,其实是开发者自己来定义的,要根据系统需求和数据库表设计进行综合考量,这里只是给出一个思路。

Reference

SpringSecurity原理剖析与权限系统设计 SpringSecurity动态鉴权流程解析 | 掘金新人第二弹 官方文档 Part II. Servlet Applications